# -*- coding: utf-8 -*-
"""
Auteur: Félix Cloutier

Le modèle de sauvegarde du serveur est un peu "original". Au lieu d'avoir des
méthodes définies pour chaque classe, le serveur conduit un travail générique sur les
objets qu'on lui donne et les classes qu'on lui demande afin de pouvoir stocker 
n'importe quelle sorte d'objets sans se soucier de leur structure réelle.

Toutes les classes stockables sont tenues d'avoir deux attributs:
    le premier est attrDefs, qui définit sous forme de dictionnaire les attributs
     des classes. Il a été établit que dans tous les cas où un objet contient des objets
     d'une autre classe, c'est pour en contenir une collection; par conséquent, pour
     définir qu'un attribut doit contenir des objets d'une classe, il faut mettre
     l'objet de la classe dans une liste. (Si cette explication semble obscure, les
     exemples multiples dans le projet devraient éclairer vos interrogations.)
     
    le second est dependsOn. Cet attribut sert à déterminer sur quelle classe les
     objets d'une seconde classe reposent. Par exemple, il faut absolument qu'un objet
     de type Action soit contenu dans un objet de type Sheet. Le but de cet attribut est
     de pouvoir déterminer quand un objet déshérité doit être supprimé de la base de données

"""

import cPickle
import os
import sys
import socket
import traceback
import xmlrpclib
import SimpleXMLRPCServer as sxmlrpcserver
from sqlite3 import dbapi2 as SQLite

import TextualAnalysis
import Calendar
import Sheet

typeToSQLite = {unicode: "TEXT", str: "TEXT", int: "INTEGER", long: "INTEGER", float: "REAL", bool: "INTEGER"}

# debug
LOG_QUERIES = False

# inutilisés
AUTH_READ = 1
AUTH_WRITE = 2

""" Retourne une chaîne de caractères qu'on peut utiliser sans risque dans une requête SQL.
    Utilisé lorsqu'il faut passé des strings directement dans les requêtes. """
def SQLiteSafeString(string):
    return string.replace('\\', '\\\\').replace("'", "\\'")

""" Une sorte de str.join(sequence) qui donne une string utilisable dans une requête SQL. """
def SQLiteJoin(iterable):
    out = ""
    for item in iterable:
        if type(item) == str:
            item = "'" + SQLiteSafeString(item) + "'"
        out += str(item)
        out += ','
    return out[:-1]

""" Indique si un identifiant est valide pour un nom de champ SQLite. """
def isValidIdentifier(str):
    return str.isalnum() and str[0].isalpha()

""" Cette sous-classe de SimpleXMLRPCServer permet d'afficher les exceptions dans la console du serveur
    au lieu de seulement dans une string illisible dans le client. """
class XMLRPCServer(sxmlrpcserver.SimpleXMLRPCServer):
    def _dispatch(self, method, params):
        try:
            return sxmlrpcserver.SimpleXMLRPCServer._dispatch(self, method, params)
        except:
            trace = ''.join(traceback.format_exc(sys.exc_info()))
            print trace
            raise xmlrpclib.Fault(1, trace)

""" Cette classe colle tout ensemble (base de données, XMLRPC).
    Il y a une base de données par projet, et tous les projets ouverts ont une connexion ouverte en même temps.
    Responsabilités:
        - serve()
        - setSelectedProject(project)
        - execute(query)
        - begin()
        - commit()
        - getObjectsOfClass(class)
        - setObject(object)
        - objectExists(object)
        - insertObject(object)
        - updateObject(object)
        - setObjectAssociates(object)
        - deleteObject(object)
        - deleteObjectAssociates(object)
        - deleteReference(objectA, objectB)
        - createReference(objectA, objectB)
        - createTableIfNecessary(class)
        - getObjectsFromClassTable(class)
        - createInstance(class)
        - createTableForClass(class)
        - createJunctionTable(classA, classB)
        - hasTableForClass(class)
        - getClassCache(class)
        - createTemporaryCacheEntry(class)
    Collaborations:
        - le serveur XMLRPC (self.rpc)
        - les connexions aux bases de données
        - les objets en cache (le serveur garde tous les objets demandés et sauvegardés en cache jusqu'à sa mort)
        - le projet actif (projet demandé par l'utilisateur)
        - le numéro global de révision (pour éviter la récursion infinie dans certains cas de sauvegarde)
        
"""
class ProjectServer(object):
    def __init__(self, dbdir, port = 8000):
        self.rpc = XMLRPCServer(("localhost", port))
        self.rpc.allow_none = True
        self.cachedInstances = {}
        self.dbdir = dbdir
        self.databases = {}
        self.project = None
        self.globalRevision = 0
        self.reloadProjects()
        
        # n'exposer que les méthodes qu'il faut
        # (et pickler/unpickler tout ce qu'il faut)
        """ Cette classe contient les méthodes exposées par XMLRPC.
            Responsabilités:
                - getObjectsOfClass(projet, classe):
                    retourne tous les objets d'une classe que le projet contient.
                - setObject(projet, object):
                    sauvegarde un objet dans la base de données.
                - deleteObject(project, object):
                    supprime un objet de la base de données.
                - deleteObjects(project, list):
                    supprime les objets contenus dans la liste de la base de données.
                - getProjects()
                    retourne la liste des projets créés.
            Collaborateurs: aucun (elle utilise ceux de la classe parent)
        """
        class ServerProxy:
            def getObjectsOfClass(_self, project, pickledClass):
                self.setSelectedProject(project)
                desiredClass = cPickle.loads(pickledClass)
                result = self.getObjectsOfClass(desiredClass)
                return cPickle.dumps(result)
            
            def setObject(_self, project, pickledObject):
                self.setSelectedProject(project)
                self.globalRevision += 1
                anObject = cPickle.loads(pickledObject)
                anObject.__revision = self.globalRevision
                returnObject = self.setObject(anObject)
                return cPickle.dumps(returnObject)
            
            def deleteObject(_self, project, anObject):
                self.setSelectedProject(project)
                return self.deleteObject(cPickle.loads(anObject))
            
            def deleteObjects(_self, project, objectList):
                self.setSelectedProject(project)
                value = True
                for anObject in cPickle.loads(objectList):
                    value &= self.deleteObject(anObject)
                return value
            
            def getProjects(_self):
                return cPickle.dumps(self.databases.keys())
        
        self.proxy = ServerProxy()
        self.rpc.register_instance(self.proxy)
    
    """ retourne la liste des projets déjà créés """
    def reloadProjects(self):
        if not os.path.exists(self.dbdir):
            os.mkdir(self.dbdir)
        for file in os.listdir(self.dbdir):
            if file[-3:] == ".db":
                dbname = file[:-3]
                if not self.databases.has_key(dbname):
                    self.databases[dbname] = None
    
    """ accès rapide à la méthode serve_forever """
    def serve(self):
        self.rpc.serve_forever()
    
    """ change le projet en cours. Le serveur accepte des modifications sur un projet à la fois;
        cette méthode définit de quel projet il s'agit.
        Si le projet n'a pas de connexion à la base de données ouverte, le serveur en ouvre une ici. """
    def setSelectedProject(self, project):
        if not self.databases.has_key(project) or self.databases[project] == None:
            dbpath = self.dbdir + os.sep + project + ".db"
            self.databases[project] = SQLite.connect(dbpath, isolation_level = None)
        self.project = project
    
    """ exécute une requête SQL et retourne le curseur associé.
        Si LOG_QUERIES est activé, affiche la requête exécutée. """
    def execute(self, query, *args):
        query += ';'
        if LOG_QUERIES:
            print "SQL " + self.project + "> " + query
            for arg in args:
                print "... " + str(type(arg)) + ' ' + str(arg)
        cursor = self.databases[self.project].cursor()
        cursor.execute(query, args)
        return cursor
    
    """ aurait été utile dans un futur quelconque. N'a aucun effet pour le moment. """
    def begin(self):
        #self.cursor = self.database.cursor()
        pass
    
    """ aurait été utile dans un futur quelconque. N'a aucun effet pour le moment. """
    def commit(self):
        #self.cursor.commit()
        pass
    
    """ retourne la liste des objets d'une certaine classe que le projet contient.
        crée la table pour stocker les objets associés si besoin est. """
    def getObjectsOfClass(self, desiredClass):
        self.begin()
        self.createTableIfNecessary(desiredClass)
        retval = self.getObjectsFromClassTable(desiredClass)
        self.commit()
        return retval
    
    """ sauvegarde un objet dans le projet. L'insère s'il n'existe pas, le met à jour
        s'il existe déjà. Si la table pour la classe n'existe pas, elle est créée. """
    def setObject(self, anObject):
        if not hasattr(anObject, "__revision"):
            anObject.__revision = 0
        if anObject.__revision == self.globalRevision:
            return anObject
        
        self.begin()
        self.createTableIfNecessary(anObject.__class__)
        if not self.objectExists(anObject):
            retval = self.insertObject(anObject)
        else:
            retval = self.updateObject(anObject)
        self.commit()
        return retval
    
    """ indique si un objet existe dans la base de données.
        Il est considéré comme inexistant si sa table n'existe pas;
            ou si l'objet n'a pas d'attribut id;
            ou s'il n'y a pas d'objet avec l'id utilisé.
        Lance une exception si l'attribut id n'est pas un int.
    """
    def objectExists(self, anObject):
        desiredClass = anObject.__class__
        if not self.hasTableForClass(desiredClass):
            return False
        
        if not hasattr(anObject, "id") or anObject.id == None:
            return False
        
        if type(anObject.id) != int:
            raise TypeError("Passing object of class " + desiredClass.__name__  + " with non-integer id")
        
        return self.execute("SELECT 1 FROM " + desiredClass.__name__ + " WHERE id = ?", anObject.id).fetchone() != None
    
    """ insère un objet dans la base de données.
        Cette méthode est appelée par setObject, qui détermine s'il faut insérer ou mettre à jour un objet.
    """
    def insertObject(self, anObject):
        if anObject.__revision == self.globalRevision: return anObject
        desiredClass = anObject.__class__
        fields = []
        arguments = []
        for (key, value) in desiredClass.attrDefs.iteritems():
            if key == 'id': continue
            if value in typeToSQLite.keys():
                fields.append(key)
                arguments.append(getattr(anObject, key))
        separator = ','
        questionMarks = ('?, ' * len(arguments))[:-2]
        query = "INSERT INTO %s (%s) VALUES (%s)" % (desiredClass.__name__, separator.join(fields), questionMarks)
        cursor = self.execute(query, *arguments)
        anObject.id = cursor.lastrowid
        
        anObject.__revision = self.globalRevision
        self.createTemporaryCacheEntry(anObject)
        self.setObjectAssociates(anObject)
        self.getClassCache(desiredClass)[anObject.id] = anObject
        
        return anObject
    
    """ met à jour un objet dans la base de données.
        Cette méthode est appelée par setObject, qui détermine s'il faut insérer ou mettre à jour un objet.
    """
    def updateObject(self, anObject):
        if anObject.__revision == self.globalRevision: return anObject
        desiredClass = anObject.__class__
        arguments = []
        query = "UPDATE %s SET " % desiredClass.__name__
        for (key, value) in desiredClass.attrDefs.iteritems():
            if key == 'id': continue
            if value in typeToSQLite.keys():
                query += key + ' = ?, '
                arguments.append(getattr(anObject, key))
        
        query = query[:-2] + " WHERE id = ?"
        arguments.append(anObject.id)
        self.execute(query, *arguments)
        
        anObject.__revision = self.globalRevision
        self.getClassCache(desiredClass)[anObject.id].__revision = self.globalRevision
        self.setObjectAssociates(anObject)
        self.getClassCache(desiredClass)[anObject.id] = anObject
        
        return anObject
    
    """ met à jour les associations entre les objets.
        Ces associations sont gardées dans des tables de jonction (voir createJunctionTable).
        Elles sont assez simples: deux champs, un pour l'ID des deux objets associés.
    """
    def setObjectAssociates(self, anObject):
        desiredClass = anObject.__class__
        associates = []
        for (key, value) in desiredClass.attrDefs.iteritems():
            if value not in typeToSQLite.keys() and hasattr(value, '__iter__') and len(value) == 1:
                associates.append(key)
        
        for associateField in associates:
            associateClass = desiredClass.attrDefs[associateField][0]
            if self.getClassCache(desiredClass).has_key(anObject.id):
                serversideAssociates = getattr(self.getClassCache(desiredClass)[anObject.id], associateField)
            else:
                serversideAssociates = getattr(self.getObjectsFromClassTable(desiredClass, [anObject.id])[0], associateField)
            
            clientsideAssociates = getattr(anObject, associateField)
            
            serversideIds = set([obj.id for obj in serversideAssociates])
            clientsideIds = set([obj.id for obj in clientsideAssociates if hasattr(obj, "id") and type(obj.id) == int])
            createUs = [obj for obj in clientsideAssociates if not hasattr(obj, "id") or type(obj.id) != int]
            
            serversideDict = {}
            for associate in serversideAssociates:
                serversideDict[associate.id] = associate
            clientsideDict = {}
            for associate in clientsideAssociates:
                if hasattr(associate, "id") and type(associate.id) == int:
                    clientsideDict[associate.id] = associate
            
            deleteSet = serversideIds - clientsideIds
            for toDelete in deleteSet:
                self.deleteReference(anObject, serversideDict[toDelete])
                self.deleteObject(serversideDict[toDelete])
            updateSet = clientsideIds & serversideIds
            for toUpdate in updateSet:
                toUpdate = self.setObject(clientsideDict[toUpdate])
            for toCreate in createUs:
                newObject = self.setObject(toCreate)
                toCreate.id = newObject.id
                toCreate.__revision = newObject.__revision
                self.createReference(anObject, toCreate)
    
    """ supprime un objet de la base de données, ainsi que toutes ses associations. """
    def deleteObject(self, anObject):
        desiredClass = anObject.__class__
        if desiredClass.dependsOn == None:
            self.deleteObjectAssociates(anObject)
            self.execute("DELETE FROM " + desiredClass.__name__ + " WHERE id = ?", anObject.id)
            del self.getClassCache(desiredClass)[anObject.id]
        else:
            for dependencyClass in desiredClass.dependsOn:
                cursor = self.execute("SELECT 1 FROM %sTo%s WHERE indexB = ?" % (dependencyClass.__name__, desiredClass.__name__), anObject.id)
                if cursor.fetchone() != None:
                    break
            else:
                self.deleteObjectAssociates(anObject)
                self.execute("DELETE FROM " + desiredClass.__name__ + " WHERE id = ?", anObject.id)
                if self.getClassCache(desiredClass).has_key(anObject.id):
                    del self.getClassCache(desiredClass)[anObject.id]
        return True
    
    """ supprime les objets associéss d'un objet. """
    def deleteObjectAssociates(self, anObject):
        desiredClass = anObject.__class__
        associates = {}
        for (key, value) in desiredClass.attrDefs.iteritems():
            if value not in typeToSQLite.keys() and hasattr(value, '__iter__') and len(value) == 1:
                associates[key] = value[0]
        
        for associate in associates.itervalues():
            self.execute("DELETE FROM %sTo%s WHERE indexA = ?" % (desiredClass.__name__, associate.__name__), anObject.id)
    
    """ supprime un lien d'association entre deux objets. """
    def deleteReference(self, objectA, objectB):
        classA = objectA.__class__
        classB = objectB.__class__
        self.execute("DELETE FROM %sTo%s WHERE indexA = ? AND indexB = ?" % (classA.__name__, classB.__name__), objectA.id, objectB.id)
    
    """ crée un lien d'association entre deux objets. """
    def createReference(self, objectA, objectB):
        classA = objectA.__class__
        classB = objectB.__class__
        self.execute("INSERT INTO %sTo%s VALUES (?, ?)" % (classA.__name__, classB.__name__), objectA.id, objectB.id)
    
    """ vérifie s'il faut créer une table pour une classe. Si oui, la crée. """
    def createTableIfNecessary(self, desiredClass):
        if not self.hasTableForClass(desiredClass):
            self.createTableForClass(desiredClass)
    
    """ obtient les objets d'une classe. Présuppose que la table associée existe.
        Va récupérer dans la base de données uniquement les objets qui ne sont pas en cache. """
    def getObjectsFromClassTable(self, desiredClass, idList = None):
        className = desiredClass.__name__
        if idList != None:
            request = set(idList)
            cachedIds = set(self.getClassCache(desiredClass).keys()) & request
            fetchIds = request - cachedIds
            cursor = self.execute("SELECT * FROM %s WHERE id IN(%s)" % (className, SQLiteJoin(fetchIds)))
        else:
            cachedIds = self.getClassCache(desiredClass).keys()
            cursor = self.execute("SELECT * FROM %s WHERE id NOT IN(%s)" % (className, SQLiteJoin(cachedIds)))
        
        results = []
        for objectId in cachedIds:
            results.append(self.getClassCache(desiredClass)[objectId])
        
        for row in cursor:
            results.append(self.createInstance(desiredClass, row))
        
        return results
    
    """ crée une instance d'une classe.
        classe.__init__ n'était pas envisageable parce que ces méthodes ont des
        prototypes franchement différents entre toutes les classes.
        Se base sur le contenu de classe.attrDefs pour créer les variables d'instance nécessaires. """
    def createInstance(self, desiredClass, row):
        newObject = object.__new__(desiredClass)
        associates = {}
        newObject.id = row[0]
        i = 1
        for (key, value) in desiredClass.attrDefs.iteritems():
            if key == "id": continue
            if value in typeToSQLite.keys():
                setattr(newObject, key, row[i])
                if type(row[i]) == unicode:
                    setattr(newObject, key, getattr(newObject, key).strip())
                i += 1
            else:
                associates[key] = value[0]
        
        self.getClassCache(desiredClass)[newObject.id] = newObject
        
        for (key, associate) in associates.iteritems():
            ids = [row[0] for row in self.execute("SELECT indexB FROM %sTo%s WHERE indexA = ?" % (desiredClass.__name__, associate.__name__), newObject.id)]
            setattr(newObject, key, self.getObjectsFromClassTable(associate, ids))
        return newObject
    
    """ Crée une table pour contenir les objets d'une classe. """
    def createTableForClass(self, desiredClass):
        query = "CREATE TABLE " + desiredClass.__name__ + " (id INTEGER PRIMARY KEY, "
        associations = {}
        for (key, value) in desiredClass.attrDefs.iteritems():
            if key == "id": continue
            if value in typeToSQLite.keys():
                if not isValidIdentifier(key):
                    raise TypeError
                query += key + " " + typeToSQLite[value] + ", "
            else:
                associations[key] = value[0]
        
        query = query[:-2] + ")"
        self.execute(query)
        
        for (key, associateClass) in associations.iteritems():
            self.createTableIfNecessary(associateClass)
            self.createJunctionTable(desiredClass, associateClass)
    
    """ Crée une table de jonction pour contenir les liens entre des objets. """
    def createJunctionTable(self, classA, classB):
        query = "CREATE TABLE IF NOT EXISTS %sTo%s (indexA INTEGER, indexB INTEGER)" % (classA.__name__, classB.__name__)
        self.execute(query)
    
    """ indique si une table existe pour une classe donnée. """
    def hasTableForClass(self, desiredClass):
        if not hasattr(desiredClass, 'attrDefs'):
            raise TypeError("Trying to store class " + desiredClass.__name__ + " that has no attribute attrDefs")
        cursor = self.execute("SELECT 1 FROM SQLite_Master WHERE Type = 'table' AND Name = ?", desiredClass.__name__)
        row = cursor.fetchone()
        return row != None
    
    """ renvoit le dictionnaire des objets en cache. Celui-ci dépend évidemment du projet en cours.
        Le dictionnaire est une association d'objets avec leur attribut ID. """
    def getClassCache(self, desiredClass):
        if not self.cachedInstances.has_key(self.project):
            self.cachedInstances[self.project] = {}
        if not self.cachedInstances[self.project].has_key(desiredClass):
            self.cachedInstances[self.project][desiredClass] = {}
        return self.cachedInstances[self.project][desiredClass]
    
    """ crée une entrée temporaire dans le cache pour un objet.
        Il est nécessaire d'en créer une pour éviter des problèmes de récursion
        infinie dans setObject et ses méthodes sous-jacentes. """
    def createTemporaryCacheEntry(self, anObject):
        # make up a dummy object with no associates
        desiredClass = anObject.__class__
        temp = object.__new__(desiredClass)
        for field, fieldType in desiredClass.attrDefs.iteritems():
            if type(fieldType) not in (list, tuple):
                setattr(temp, field, getattr(anObject, field))
            else:
                setattr(temp, field, [])
        self.getClassCache(desiredClass)[temp.id] = temp


if __name__ == '__main__':
    if os.name == "posix":
        server = ProjectServer('/tmp/projects')
    else:
        server = ProjectServer("C:\\temp\\projects")
    server.serve()
